/**
 * 
 */
package org.holodeckb2b.as2.util;

import java.text.ParseException;
import java.text.ParsePosition;
import java.util.Calendar;
import java.util.Date;
import java.util.GregorianCalendar;
import java.util.TimeZone;

/**
 * Utility class for parsing a MIME header date value. This class is a copy of the parser contained in Apache Geronimo 
 * implementation of <code>javax.mail.internet.MailDateFormat</code>. That implementation however  prints a stack 
 * trace to stdout when the parsed value is invalid. To prevent this we extracted the parser from that class.
 * 
 * @author Sander Fieten (sander at chasquis-consulting.com)
 */
public class MimeDateParser {
	// our list of defined whitespace characters
	static final String whitespace = " \t\r\n";

	// current parsing position
	int current;
	// our end parsing position
	int endOffset;
	// the date source string
	String source;
	// The parsing position. We update this as we move along and
	// also for any parsing errors
	ParsePosition pos;

	public MimeDateParser(String source) {
		this.source = source;
		this.pos = new ParsePosition(0);
		// we start using the providing parsing index.
		this.current = pos.getIndex();
		this.endOffset = source.length();
	}

	/**
	 * Parse the timestamp using strict setting, returning a date object.
	 * 
	 * @param lenient
	 *            The lenient setting from the Formatter object.
	 * 
	 * @return A Date object based off of parsing the date string
	 * @exception ParseException
	 */
	public Date parse() throws ParseException {
		return parse(false);
	}
	
	/**
	 * Parse the timestamp, returning a date object.
	 * 
	 * @param lenient
	 *            The lenient setting from the Formatter object.
	 * 
	 * @return A Date object based off of parsing the date string.
	 * @exception ParseException
	 */
	public Date parse(boolean lenient) throws ParseException {
		// we just skip over any next date format, which means scanning ahead until we
		// find the first numeric character
		locateNumeric();
		// the day can be either 1 or two digits
		int day = parseNumber(1, 2);
		// step over the delimiter
		skipDateDelimiter();
		// parse off the month (which is in character format)
		int month = parseMonth();
		// step over the delimiter
		skipDateDelimiter();
		// now pull of the year, which can be either 2-digit or 4-digit
		int year = parseYear();
		// white space is required here
		skipRequiredWhiteSpace();
		// accept a 1 or 2 digit hour
		int hour = parseNumber(1, 2);
		skipRequiredChar(':');
		// the minutes must be two digit
		int minutes = parseNumber(2, 2);

		// the seconds are optional, but the ":" tells us if they are to
		// be expected.
		int seconds = 0;
		if (skipOptionalChar(':')) {
			seconds = parseNumber(2, 2);
		}
		// skip over the white space
		skipWhiteSpace();
		// and finally the timezone information
		int offset = parseTimeZone();

		// set the index of how far we've parsed this
		pos.setIndex(current);

		// create a calendar for creating the date
		Calendar greg = new GregorianCalendar(TimeZone.getTimeZone("GMT"));
		// we inherit the leniency rules
		greg.setLenient(lenient);
		greg.set(year, month, day, hour, minutes, seconds);
		// now adjust by the offset. This seems a little strange, but we
		// need to negate the offset because this is a UTC calendar, so we need to
		// apply the reverse adjustment. for example, for the EST timezone, the offset
		// value will be -300 (5 hours). If the time was 15:00:00, the UTC adjusted time
		// needs to be 20:00:00, so we subract -300 minutes.
		greg.add(Calendar.MINUTE, -offset);
		// now return this timestamp.
		return greg.getTime();
	}

	/**
	 * Skip over a position where there's a required value expected.
	 * 
	 * @param ch
	 *            The required character.
	 * 
	 * @exception ParseException
	 */
	private void skipRequiredChar(char ch) throws ParseException {
		if (current >= endOffset) {
			parseError("Delimiter '" + ch + "' expected");
		}
		if (source.charAt(current) != ch) {
			parseError("Delimiter '" + ch + "' expected");
		}
		current++;
	}

	/**
	 * Skip over a position where iff the position matches the character
	 * 
	 * @param ch
	 *            The required character.
	 * 
	 * @return true if the character was there, false otherwise.
	 * @exception ParseException
	 */
	private boolean skipOptionalChar(char ch) {
		if (current >= endOffset) {
			return false;
		}
		if (source.charAt(current) != ch) {
			return false;
		}
		current++;
		return true;
	}

	/**
	 * Skip over any white space characters until we find the next real bit of
	 * information. Will scan completely to the end, if necessary.
	 */
	private void skipWhiteSpace() {
		while (current < endOffset) {
			// if this is not in the white space list, then success.
			if (whitespace.indexOf(source.charAt(current)) < 0) {
				return;
			}
			current++;
		}

		// everything used up, just return
	}

	/**
	 * Skip over any non-white space characters until we find either a whitespace
	 * char or the end of the data.
	 */
	private void skipNonWhiteSpace() {
		while (current < endOffset) {
			// if this is not in the white space list, then success.
			if (whitespace.indexOf(source.charAt(current)) >= 0) {
				return;
			}
			current++;
		}

		// everything used up, just return
	}

	/**
	 * Skip over any white space characters until we find the next real bit of
	 * information. Will scan completely to the end, if necessary.
	 */
	private void skipRequiredWhiteSpace() throws ParseException {
		int start = current;

		while (current < endOffset) {
			// if this is not in the white space list, then success.
			if (whitespace.indexOf(source.charAt(current)) < 0) {
				// we must have at least one white space character
				if (start == current) {
					parseError("White space character expected");
				}
				return;
			}
			current++;
		}
		// everything used up, just return, but make sure we had at least one
		// white space
		if (start == current) {
			parseError("White space character expected");
		}
	}

	private void parseError(String message) throws ParseException {
		// we've got an error, set the index to the end.
		pos.setErrorIndex(current);
		throw new ParseException(message, current);
	}

	/**
	 * Locate an expected numeric field.
	 * 
	 * @exception ParseException
	 */
	private void locateNumeric() throws ParseException {
		while (current < endOffset) {
			// found a digit? we're done
			if (Character.isDigit(source.charAt(current))) {
				return;
			}
			current++;
		}
		// we've got an error, set the index to the end.
		parseError("Number field expected");
	}

	/**
	 * Parse out an expected numeric field.
	 * 
	 * @param minDigits
	 *            The minimum number of digits we expect in this filed.
	 * @param maxDigits
	 *            The maximum number of digits expected. Parsing will stop at the
	 *            first non-digit character. An exception will be thrown if the
	 *            field contained more than maxDigits in it.
	 * 
	 * @return The parsed numeric value.
	 * @exception ParseException
	 */
	private int parseNumber(int minDigits, int maxDigits) throws ParseException {
		int start = current;
		int accumulator = 0;
		while (current < endOffset) {
			char ch = source.charAt(current);
			// if this is not a digit character, then quit
			if (!Character.isDigit(ch)) {
				break;
			}
			// add the digit value into the accumulator
			accumulator = accumulator * 10 + Character.digit(ch, 10);
			current++;
		}

		int fieldLength = current - start;
		if (fieldLength < minDigits || fieldLength > maxDigits) {
			parseError("Invalid number field");
		}

		return accumulator;
	}

	/**
	 * Skip a delimiter between the date portions of the string. The IMAP internal
	 * date format uses "-", so we either accept a single "-" or any number of white
	 * space characters (at least one required).
	 * 
	 * @exception ParseException
	 */
	private void skipDateDelimiter() throws ParseException {
		if (current >= endOffset) {
			parseError("Invalid date field delimiter");
		}

		if (source.charAt(current) == '-') {
			current++;
		} else {
			// must be at least a single whitespace character
			skipRequiredWhiteSpace();
		}
	}

	/**
	 * Parse a character month name into the date month offset.
	 * 
	 * @return
	 * @exception ParseException
	 */
	private int parseMonth() throws ParseException {
		if ((endOffset - current) < 3) {
			parseError("Invalid month");
		}

		int monthOffset = 0;
		String month = source.substring(current, current + 3).toLowerCase();

		if (month.equals("jan")) {
			monthOffset = 0;
		} else if (month.equals("feb")) {
			monthOffset = 1;
		} else if (month.equals("mar")) {
			monthOffset = 2;
		} else if (month.equals("apr")) {
			monthOffset = 3;
		} else if (month.equals("may")) {
			monthOffset = 4;
		} else if (month.equals("jun")) {
			monthOffset = 5;
		} else if (month.equals("jul")) {
			monthOffset = 6;
		} else if (month.equals("aug")) {
			monthOffset = 7;
		} else if (month.equals("sep")) {
			monthOffset = 8;
		} else if (month.equals("oct")) {
			monthOffset = 9;
		} else if (month.equals("nov")) {
			monthOffset = 10;
		} else if (month.equals("dec")) {
			monthOffset = 11;
		} else {
			parseError("Invalid month");
		}

		// ok, this is valid. Update the position and return it
		current += 3;
		return monthOffset;
	}

	/**
	 * Parse off a year field that might be expressed as either 2 or 4 digits.
	 * 
	 * @return The numeric value of the year.
	 * @exception ParseException
	 */
	private int parseYear() throws ParseException {
		// the year is between 2 to 4 digits
		int year = parseNumber(2, 4);

		// the two digit years get some sort of adjustment attempted.
		if (year < 50) {
			year += 2000;
		} else if (year < 100) {
			year += 1990;
		}
		return year;
	}

	/**
	 * Parse all of the different timezone options.
	 * 
	 * @return The timezone offset.
	 * @exception ParseException
	 */
	private int parseTimeZone() throws ParseException {
		if (current >= endOffset) {
			parseError("Missing time zone");
		}

		// get the first non-blank. If this is a sign character, this
		// is a zone offset.
		char sign = source.charAt(current);

		if (sign == '-' || sign == '+') {
			// need to step over the sign character
			current++;
			// a numeric timezone is always a 4 digit number, but
			// expressed as minutes/seconds. I'm too lazy to write a
			// different parser that will bound on just a couple of characters, so
			// we'll grab this as a single value and adjust
			int zoneInfo = parseNumber(4, 4);

			int offset = (zoneInfo / 100) * 60 + (zoneInfo % 100);
			// negate this, if we have a negativeo offset
			if (sign == '-') {
				offset = -offset;
			}
			return offset;
		} else {
			// need to parse this out using the obsolete zone names. This will be
			// either a 3-character code (defined set), or a single character military
			// zone designation.
			int start = current;
			skipNonWhiteSpace();
			String name = source.substring(start, current).toUpperCase();

			if (name.length() == 1) {
				return militaryZoneOffset(name);
			} else if (name.length() <= 3) {
				return namedZoneOffset(name);
			} else {
				parseError("Invalid time zone");
			}
			return 0;
		}
	}

	/**
	 * Parse the obsolete mail timezone specifiers. The allowed set of timezones are
	 * terribly US centric. That's the spec. The preferred timezone form is the
	 * +/-mmss form.
	 * 
	 * @param name
	 *            The input name.
	 * 
	 * @return The standard timezone offset for the specifier.
	 * @exception ParseException
	 */
	private int namedZoneOffset(String name) throws ParseException {

		// NOTE: This is "UT", NOT "UTC"
		if (name.equals("UT")) {
			return 0;
		} else if (name.equals("GMT")) {
			return 0;
		} else if (name.equals("EST")) {
			return -300;
		} else if (name.equals("EDT")) {
			return -240;
		} else if (name.equals("CST")) {
			return -360;
		} else if (name.equals("CDT")) {
			return -300;
		} else if (name.equals("MST")) {
			return -420;
		} else if (name.equals("MDT")) {
			return -360;
		} else if (name.equals("PST")) {
			return -480;
		} else if (name.equals("PDT")) {
			return -420;
		} else {
			parseError("Invalid time zone");
			return 0;
		}
	}

	/**
	 * Parse a single-character military timezone.
	 * 
	 * @param name
	 *            The one-character name.
	 * 
	 * @return The offset corresponding to the military designation.
	 */
	private int militaryZoneOffset(String name) throws ParseException {
		switch (Character.toUpperCase(name.charAt(0))) {
		case 'A':
			return 60;
		case 'B':
			return 120;
		case 'C':
			return 180;
		case 'D':
			return 240;
		case 'E':
			return 300;
		case 'F':
			return 360;
		case 'G':
			return 420;
		case 'H':
			return 480;
		case 'I':
			return 540;
		case 'K':
			return 600;
		case 'L':
			return 660;
		case 'M':
			return 720;
		case 'N':
			return -60;
		case 'O':
			return -120;
		case 'P':
			return -180;
		case 'Q':
			return -240;
		case 'R':
			return -300;
		case 'S':
			return -360;
		case 'T':
			return -420;
		case 'U':
			return -480;
		case 'V':
			return -540;
		case 'W':
			return -600;
		case 'X':
			return -660;
		case 'Y':
			return -720;
		case 'Z':
			return 0;
		default:
			parseError("Invalid time zone");
			return 0;
		}
	}
}
